/**
 * GcmServer
 * GcmSender.java
 * 
 * Copyright 2013 Teachizen
 * Written by cloudstick
 *	2013. 8. 5. first written.
 *
 * note:
 * 		Copy from google gcm server implementation.
 * 		- com.google.android.gcm.server.Sender.java
 */
package com.bnsworks.server.push.gcm.sender;

import static com.bnsworks.server.push.gcm.GcmConstants.GCM_SEND_ENDPOINT;
import static com.bnsworks.server.push.gcm.GcmConstants.TOKEN_CANONICAL_REG_ID;
import static com.bnsworks.server.push.gcm.GcmConstants.TOKEN_ERROR;
import static com.bnsworks.server.push.gcm.GcmConstants.TOKEN_MESSAGE_ID;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Random;

import org.codehaus.jackson.map.ObjectMapper;

import com.bnsworks.server.push.gcm.GcmConstants;
import com.bnsworks.server.push.gcm.GcmException;
import com.bnsworks.server.push.gcm.message.GcmMessage;
import com.bnsworks.server.push.gcm.result.GcmMulticastResult;
import com.bnsworks.server.push.gcm.result.GcmResult;

import lombok.Cleanup;
import lombok.extern.slf4j.Slf4j;

/**
 * Helper class to send messages to the GCM service using an API Key.
 * API key를 이용해서 메시지를 GCM으로 전송하기 위한 Helper클래스
 */
@Slf4j
public class GcmSender {

	/**
	 * Initial delay before first retry, without jitter.
	 * 최초 retry 전 지연 시간
	 */
	private static final int BACKOFF_INITIAL_DELAY = 1000;
	/**
	 * Maximum delay before a retry.
	 * retry 전 최대 지연 시간
	 */
	private static final int MAX_BACKOFF_DELAY = 1024000;

	private final Random random = new Random();

	private final String key;

	/**
	 * Default constructor.
	 *
	 * @param key API key obtained through the Google API Console.
	 */
	public GcmSender(String key) {
		this.key = nonNull(key);
	}

	/**
	 * Sends a message to one device, retrying in case of unavailability.
	 *
	 * <p>
	 * <strong>Note: </strong> this method uses exponential back-off to retry in
	 * case of service unavailability and hence could block the calling thread
	 * for many seconds.
	 *
	 * @param message message to be sent, including the device's registration id.
	 * @param registrationId device where the message will be sent.
	 * @param retries number of retries in case of service unavailability errors.
	 *
	 * @return result of the request (see its javadoc for more details).
	 *
	 * @throws IllegalArgumentException if registrationId is {@literal null}.
	 * @throws InvalidRequestException if GCM didn't returned a 200 or 5xx status.
	 * @throws IOException if message could not be sent.
	 */
	public GcmResult send(GcmMessage message, String registrationId, int retries) {
	    int attempt = 0;
	    GcmResult result = null;
	    int backoff = BACKOFF_INITIAL_DELAY;
	    boolean tryAgain = false;
	    
	    do {
	    	//retries 만큼 시도하면서 메시지를 registrationId로 보냄
	    	attempt++;
	    	log.info("Attempt #{} in retries {} to send message {} to regIds {}", 
	    			new Object[] { attempt, retries, message, registrationId});
	    	
	    	result = sendNoRetry(message, registrationId);
	      	tryAgain = (result == null && attempt <= retries);
	      	if (!tryAgain) break;
      		
	      	//최대 지연 시간보다 적은 값으로 backoff를 attempt마다 증가시키면서 재시도
	      	int sleepTime = backoff / 2 + random.nextInt(backoff);
      		sleep(sleepTime);
      		if (2 * backoff < MAX_BACKOFF_DELAY) {
      			backoff *= 2;
      		}
	    } while (tryAgain);
	    if (result == null) {
	    	throw new GcmException("Could not send message after " + attempt + " attempts");
	    }
	    return result;
	}

	public GcmResult parseResponse(String responseBody) {
		
		String[] lines = responseBody.split("\n");
	    if (lines.length == 0 || lines[0].equals("")) {
	    	throw new GcmException("Received empty response from GCM service.");
	    }
	    
	    GcmResult result = new GcmResult();
	    
	    String firstLine = lines[0];
	    String[] responseParts = split(firstLine);
	    String token = responseParts[0];
	    String value = responseParts[1];
	    if (token.equals(TOKEN_MESSAGE_ID)) {
	    	result.setMessage_id(value);
	    
	    	// check for canonical registration id
	    	if (lines.length > 1) {
		        String secondLine = lines[1];
		        responseParts = split(secondLine);
		        token = responseParts[0];
		        value = responseParts[1];
		        if (token.equals(TOKEN_CANONICAL_REG_ID)) {
		        	result.setRegistration_id(value);
		        } else {
		        	log.warn("Invalid response from GCM: {}", responseBody);
		        }
	    	}
	    	log.info("Message created succesfully ({})", result);
	    } else if (token.equals(TOKEN_ERROR)) {
	    	result.setError(value);
	    } else {
	    	throw new GcmException("Invalid response from GCM: " + responseBody);
	    }
	    return result;
	}

	/**
	 * Sends a message without retrying in case of service unavailability. See
	 * {@link #send(Message, String, int)} for more info.
	 *
	 * @return result of the post, or {@literal null} if the GCM service was
	 *         unavailable or any network exception caused the request to fail.
	 *
	 * @throws InvalidRequestException if GCM didn't returned a 200 or 5xx status.
	 * @throws IllegalArgumentException if registrationId is {@literal null}.
	 */
	public GcmResult sendNoRetry(GcmMessage message, String registrationId) {
		
	    String requestBody = message.getQueryMessage(registrationId);
	    log.debug("Request body: {}", requestBody);
	    
	    HttpURLConnection conn;
	    int status;
	    try {
	    	conn = post(GCM_SEND_ENDPOINT, requestBody);
	    	status = conn.getResponseCode();
	    } catch (IOException e) {
	    	log.error("IOException posting to GCM.", e);
	    	return null;
	    }
	    if (status / 100 == 5) {
	      log.info("GCM service is unavailable (status {})", status);
	      return null;
	    }
	    
	    String responseBody;
	    if (status != 200) {
	    	//5xx 에러가 아닌 경우 정상적인 통신 에러로 정의
	    	try {
	    		responseBody = getAndClose(conn.getErrorStream());
	    		log.info("Plain post error response: {}", responseBody);
	    	} catch (IOException e) {
	    		// ignore the exception since it will thrown an InvalidRequestException
	    		// anyways
	    		responseBody = "N/A";
	    		log.error("Exception reading response: ", e);
	    	}
	    	throw new GcmException(status, responseBody);
	    } else {
	    	try {
	    		responseBody = getAndClose(conn.getInputStream());
	    	} catch (IOException e) {
		        log.error("Exception reading response: ", e);
		        // return null so it can retry
		        return null;
	    	}
	    }
	    
	    //정상적인 응답 메시지에 대한 파싱
	    return parseResponse(responseBody);
	}

	/**
	 * Sends a message to many devices, retrying in case of unavailability.
	 *
	 * <p>
	 * <strong>Note: </strong> this method uses exponential back-off to retry in
	 * case of service unavailability and hence could block the calling thread
	 * for many seconds.
	 *
	 * @param message message to be sent.
	 * @param regIds registration id of the devices that will receive
	 *        the message.
	 * @param retries number of retries in case of service unavailability errors.
	 *
	 * @return combined result of all requests made.
	 *
	 * @throws IllegalArgumentException if registrationIds is {@literal null} or
	 *         empty.
	 * @throws InvalidRequestException if GCM didn't returned a 200 or 503 status.
	 * @throws IOException if message could not be sent.
	 */
	public GcmMulticastResult send(GcmMessage message, List<String> regIds, int retries) throws IOException {
	    
		int attempt = 0;
	    GcmMulticastResult multicastResult;
	    int backoff = BACKOFF_INITIAL_DELAY;
	    // Map of results by registration id, it will be updated after each attempt
	    // to send the messages
	    Map<String, GcmResult> results = new HashMap<String, GcmResult>();
	    List<String> unsentRegIds = new ArrayList<String>(regIds);
	    boolean tryAgain = false;
	    List<Long> multicastIds = new ArrayList<Long>();
	    
	    do {
	    	multicastResult = null;
	    	attempt++;
	    	log.info("Attempt #{} in retries {} to send message {} to regIds {}",
	    			new Object[] { attempt, retries, message, unsentRegIds });
	    	try {
	    		multicastResult = sendNoRetry(message, unsentRegIds);
	    	} catch(IOException e) {
	    		// no need for WARNING since exception might be already logged 
	    		log.info("IOException on attempt {}", attempt, e);
	    	}
    		if ( multicastResult != null ) {
	    		long multicastId = multicastResult.getMulticast_id();
	    		log.debug("multicast_id on attempt # {}:{}", attempt, multicastId);
	    		multicastIds.add(multicastId);
	    		unsentRegIds = updateStatus(unsentRegIds, results, multicastResult);
	    		tryAgain = !unsentRegIds.isEmpty() && attempt <= retries;
    		} else {
    			tryAgain = attempt <= retries;
    		}
    		if (tryAgain) {
    			int sleepTime = backoff / 2 + random.nextInt(backoff);
    			sleep(sleepTime);
    			if (2 * backoff < MAX_BACKOFF_DELAY) {
    				backoff *= 2;
    			}
    		}
	    } while (tryAgain);
	    
	    if (multicastIds.isEmpty()) {
	    	// all JSON posts failed due to GCM unavailability
	    	throw new GcmException("Could not post JSON requests to GCM after " + attempt + " attempts");
	    }
	    // calculate summary
	    int success = 0, failure = 0 , canonicalIds = 0;
	    for (GcmResult result : results.values()) {
	    	if (result.getMessage_id() != null) {
		        success++;
		        if (result.getRegistration_id() != null) {
		        	canonicalIds++;
		        }
	    	} else {
	    		failure++;
	    	}
	    }
	    // build a new object with the overall result
	    long multicastId = multicastIds.remove(0);
	    
	    multicastResult = new GcmMulticastResult();
	    multicastResult.setSuccess(success);
	    multicastResult.setFailure(failure);
	    multicastResult.setCanonical_ids(canonicalIds);
	    multicastResult.setMulticast_id(multicastId);
	    multicastResult.setResults(new ArrayList<GcmResult>());
	    multicastResult.setRetryMulticastIds(multicastIds);
	    
	    // add results, in the same order as the input
	    for (String regId : regIds) {
	    	multicastResult.getResults().add(results.get(regId));
	    }
	    return multicastResult;
	}

	/**
	 * Updates the status of the messages sent to devices and the list of devices
	 * that should be retried.
	 *
	 * @param unsentRegIds list of devices that are still pending an update.
	 * @param allResults map of status that will be updated.
	 * @param multicastResult result of the last multicast sent.
	 *
	 * @return updated version of devices that should be retried.
	 */
	private List<String> updateStatus(List<String> unsentRegIds,
			Map<String, GcmResult> allResults, GcmMulticastResult multicastResult) {
		
	    List<GcmResult> results = multicastResult.getResults();
	    if (results.size() != unsentRegIds.size()) {
	    	// should never happen, unless there is a flaw in the algorithm
	    	throw new RuntimeException("Internal error: sizes do not match. " +
	          "currentResults: " + results + "; unsentRegIds: " + unsentRegIds);
	    }
	    List<String> newUnsentRegIds = new ArrayList<String>();
	    for (int i = 0; i < unsentRegIds.size(); i++) {
	    	String regId = unsentRegIds.get(i);
	    	GcmResult result = results.get(i);
	    	allResults.put(regId, result);
	    	String error = result.getError();
	    	if (error != null && 
	    		(error.equals(GcmConstants.ERROR_UNAVAILABLE) || 
	    		error.equals(GcmConstants.ERROR_INTERNAL_SERVER_ERROR))) {
	    		newUnsentRegIds.add(regId);
	    	}
	    }
	    return newUnsentRegIds;
	}
  
  	/**
  	 * Sends a message without retrying in case of service unavailability. See
  	 * {@link #send(Message, List, int)} for more info.
  	 *
  	 * @return multicast results if the message was sent successfully,
  	 *         {@literal null} if it failed but could be retried.
   	 *
   	 * @throws IllegalArgumentException if registrationIds is {@literal null} or
   	 *         empty.
   	 * @throws InvalidRequestException if GCM didn't returned a 200 status.
   	 * @throws IOException if there was a JSON parsing error
   	 */
  	public GcmMulticastResult sendNoRetry(GcmMessage message,
  				List<String> registrationIds) throws IOException {
  		
  		if (nonNull(registrationIds).isEmpty()) {
  			throw new GcmException("registrationIds cannot be empty");
	    }
  		
	    String requestBody = message.getJsonMessage(registrationIds);
	    log.debug("JSON request: {}", requestBody);
	    
	    HttpURLConnection conn;
	    int status;
	    try {
	    	conn = post(GCM_SEND_ENDPOINT, "application/json", requestBody);
	    	status = conn.getResponseCode();
	    } catch (IOException e) {
	    	log.error("IOException posting to GCM", e);
	    	return null;
	    }
	    
	    String responseBody;
	    if (status != 200) {
	    	try {
		    	responseBody = getAndClose(conn.getErrorStream());
		        log.error("JSON error response: {}", responseBody);
	    	} catch (IOException e) {
		        // ignore the exception since it will thrown an InvalidRequestException
		        // anyways
		        responseBody = "N/A";
		        log.error("Exception reading response: ", e);
	    	}
	    	throw new GcmException(status, responseBody);
	    }
	    
	    try {
	    	responseBody = getAndClose(conn.getInputStream());
	    } catch(IOException e) {
	    	log.error("IOException reading response", e);
	    	return null;
	    }
	    log.debug("JSON response: {}", responseBody);
	    
	    try {
	    	ObjectMapper mapper = new ObjectMapper();
	    	GcmMulticastResult multicastResult = mapper.readValue(responseBody, GcmMulticastResult.class);
	    	return multicastResult;
	    } catch(Exception e) {
	    	throw new GcmException("Exception occured.", e);
	    }
  	}
  	
  	private String[] split(String line) {
	    String[] split = line.split("=", 2);
	    if (split.length != 2) {
	    	throw new GcmException("Received invalid response line from GCM: " + line);
	    }
	    return split;
  	}

  	/**
  	 * Make an HTTP post to a given URL.
  	 *
  	 * @return HTTP response.
  	 */
  	private HttpURLConnection post(String url, String body) throws IOException {
  		return post(url, "application/x-www-form-urlencoded;charset=UTF-8", body);
  	}

  	/**
  	 * Makes an HTTP POST request to a given endpoint.
  	 *
  	 * <p>
  	 * <strong>Note: </strong> the returned connected should not be disconnected,
  	 * otherwise it would kill persistent connections made using Keep-Alive.
  	 *
  	 * @param url endpoint to post the request.
  	 * @param contentType type of request.
  	 * @param body body of the request.
  	 *
  	 * @return the underlying connection.
  	 *
  	 * @throws IOException propagated from underlying methods.
  	 */
  	private HttpURLConnection post(String url, String contentType, String body) throws IOException {
  		
  		if (url == null || body == null) {
  			throw new IllegalArgumentException("arguments cannot be null");
  		}
  		if (!url.startsWith("https://")) {
  			log.warn("URL does not use https: {}", url);
  		}
	    log.debug("Sending POST to {}", url);
	    log.debug("POST body: {}", body);
	    
	    byte[] bytes = body.getBytes();
	    HttpURLConnection conn = getConnection(url);
	    conn.setDoOutput(true);
	    conn.setUseCaches(false);
	    conn.setFixedLengthStreamingMode(bytes.length);
	    conn.setRequestMethod("POST");
	    conn.setRequestProperty("Content-Type", contentType);
	    conn.setRequestProperty("Authorization", "key=" + key);
	    
	    @Cleanup OutputStream out = conn.getOutputStream();
	    out.write(bytes);
	    
	    return conn;
  	}

  	/**
  	 * Gets an {@link HttpURLConnection} given an URL.
  	 */
  	private HttpURLConnection getConnection(String url) throws IOException {
  		HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
  		return conn;
  	}

  	/**
  	 * Convenience method to convert an InputStream to a String.
  	 * <p>
  	 * If the stream ends in a newline character, it will be stripped.
  	 * <p>
  	 * If the stream is {@literal null}, returns an empty string.
  	 */
  	private static String getString(InputStream stream) throws IOException {
	    if (stream == null) {
	    	return "";
	    }
	    BufferedReader reader = new BufferedReader(new InputStreamReader(stream));
	    StringBuilder content = new StringBuilder();
	    String newLine;
	    do {
	    	newLine = reader.readLine();
	    	if (newLine != null) {
	    		content.append(newLine).append('\n');
	    	}
	    } while (newLine != null);
	    if (content.length() > 0) {
	    	// strip last newline
	    	content.setLength(content.length() - 1);
	    }
	    return content.toString();
  	}

  	private static String getAndClose(InputStream stream) throws IOException {
  		@Cleanup InputStream is = stream;
    	return getString(is);
  	}
  
  	/**
  	 * 임의의 타입에 대한 객체의 null 확인
  	 * @param argument null 확인을 위한 객체
  	 * @return null이 아닌 경우 해당 객체
  	 */
  	static <T> T nonNull(T argument) {
	    if (argument == null) {
	    	throw new GcmException("argument cannot be null");
	    }
	    return argument;
  	}

  	void sleep(long millis) {
	    try {
	    	Thread.sleep(millis);
	    } catch (InterruptedException e) {
	    	Thread.currentThread().interrupt();
	    }
  	}
}